agentmux_srv\backend\wcore/
dnd.rs1use uuid::Uuid;
7
8use crate::backend::storage::wstore::WaveStore;
9use crate::backend::storage::StoreError;
10use crate::backend::obj::*;
11
12use super::tab::{create_tab, delete_tab, set_active_tab};
13use super::workspace::create_workspace;
14
15pub fn move_block_to_tab(
21 store: &WaveStore,
22 block_id: &str,
23 source_tab_id: &str,
24 dest_tab_id: &str,
25 ws_id: &str,
26 auto_close_source: bool,
27) -> Result<(), StoreError> {
28 tracing::info!(
29 block_id = %block_id,
30 source_tab = %source_tab_id,
31 dest_tab = %dest_tab_id,
32 ws_id = %ws_id,
33 auto_close = %auto_close_source,
34 "[dnd] move_block_to_tab"
35 );
36 if source_tab_id == dest_tab_id {
37 tracing::debug!("[dnd] move_block_to_tab: same tab, no-op");
38 return Ok(()); }
40
41 let mut block = store.must_get::<Block>(block_id)?;
43
44 let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
46 source_tab.blockids.retain(|id| id != block_id);
47 store.update(&mut source_tab)?;
48
49 let mut dest_tab = store.must_get::<Tab>(dest_tab_id)?;
51 dest_tab.blockids.push(block_id.to_string());
52 store.update(&mut dest_tab)?;
53
54 block.parentoref = format!("tab:{}", dest_tab_id);
56 store.update(&mut block)?;
57
58 if auto_close_source && source_tab.blockids.is_empty() {
60 let ws = store.must_get::<Workspace>(ws_id)?;
61 let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
62 if total_tabs > 1 {
63 tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab");
64 delete_tab(store, ws_id, source_tab_id)?;
65 } else {
66 tracing::debug!(source_tab = %source_tab_id, "[dnd] source tab empty but is last tab — keeping");
67 }
68 }
69
70 tracing::info!(block_id = %block_id, dest_tab = %dest_tab_id, "[dnd] move_block_to_tab complete");
71 Ok(())
72}
73
74pub fn promote_block_to_tab(
79 store: &WaveStore,
80 block_id: &str,
81 source_tab_id: &str,
82 ws_id: &str,
83 auto_close_source: bool,
84) -> Result<Tab, StoreError> {
85 tracing::info!(
86 block_id = %block_id,
87 source_tab = %source_tab_id,
88 ws_id = %ws_id,
89 auto_close = %auto_close_source,
90 "[dnd] promote_block_to_tab"
91 );
92 let mut block = store.must_get::<Block>(block_id)?;
94
95 let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
97 source_tab.blockids.retain(|id| id != block_id);
98 store.update(&mut source_tab)?;
99
100 let new_tab = create_tab(store, ws_id)?;
102
103 let mut new_tab = store.must_get::<Tab>(&new_tab.oid)?;
105 new_tab.blockids.push(block_id.to_string());
106 store.update(&mut new_tab)?;
107
108 block.parentoref = format!("tab:{}", new_tab.oid);
110 store.update(&mut block)?;
111
112 set_active_tab(store, ws_id, &new_tab.oid)?;
114
115 if auto_close_source && source_tab.blockids.is_empty() {
117 let ws = store.must_get::<Workspace>(ws_id)?;
118 let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
119 if total_tabs > 1 {
120 tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab after promote");
121 delete_tab(store, ws_id, source_tab_id)?;
122 }
123 }
124
125 tracing::info!(block_id = %block_id, new_tab = %new_tab.oid, "[dnd] promote_block_to_tab complete");
126 Ok(new_tab)
127}
128
129pub fn move_tab_to_workspace(
135 store: &WaveStore,
136 tab_id: &str,
137 source_ws_id: &str,
138 dest_ws_id: &str,
139 insert_index: Option<usize>,
140) -> Result<(), StoreError> {
141 tracing::info!(
142 tab_id = %tab_id,
143 source_ws = %source_ws_id,
144 dest_ws = %dest_ws_id,
145 insert_index = ?insert_index,
146 "[dnd] move_tab_to_workspace"
147 );
148 if source_ws_id == dest_ws_id {
149 tracing::debug!("[dnd] move_tab_to_workspace: same workspace, no-op");
150 return Ok(()); }
152
153 let _tab = store.must_get::<Tab>(tab_id)?;
155
156 let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
158 let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
159 if total_tabs <= 1 {
160 tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] move_tab_to_workspace blocked: last tab");
161 return Err(StoreError::Other(
162 "cannot move last tab out of workspace".to_string(),
163 ));
164 }
165 source_ws.tabids.retain(|id| id != tab_id);
166 source_ws.pinnedtabids.retain(|id| id != tab_id);
167 if source_ws.activetabid == tab_id {
168 let new_active = source_ws
169 .tabids
170 .first()
171 .or(source_ws.pinnedtabids.first())
172 .cloned()
173 .unwrap_or_default();
174 tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab in source workspace");
175 source_ws.activetabid = new_active;
176 }
177 store.update(&mut source_ws)?;
178
179 let mut dest_ws = store.must_get::<Workspace>(dest_ws_id)?;
181 let idx = insert_index.unwrap_or(dest_ws.tabids.len());
182 let insert_at = idx.min(dest_ws.tabids.len());
183 dest_ws.tabids.insert(insert_at, tab_id.to_string());
184 dest_ws.activetabid = tab_id.to_string();
185 store.update(&mut dest_ws)?;
186
187 tracing::info!(tab_id = %tab_id, dest_ws = %dest_ws_id, insert_at = %insert_at, "[dnd] move_tab_to_workspace complete");
188 Ok(())
189}
190
191pub fn tear_off_block(
196 store: &WaveStore,
197 block_id: &str,
198 source_tab_id: &str,
199 source_ws_id: &str,
200 auto_close_source: bool,
201) -> Result<Workspace, StoreError> {
202 tracing::info!(
203 block_id = %block_id,
204 source_tab = %source_tab_id,
205 source_ws = %source_ws_id,
206 auto_close = %auto_close_source,
207 "[dnd] tear_off_block"
208 );
209 let mut block = store.must_get::<Block>(block_id)?;
211
212 let mut source_tab = store.must_get::<Tab>(source_tab_id)?;
215 source_tab.blockids.retain(|id| id != block_id);
216 store.update(&mut source_tab)?;
217
218 let mut source_layout = store.must_get::<LayoutState>(&source_tab.layoutstate)?;
219 let mut actions = source_layout.pendingbackendactions.take().unwrap_or_default();
220 actions.push(LayoutActionData {
221 actiontype: "delete".to_string(),
222 actionid: Uuid::new_v4().to_string(),
223 blockid: block_id.to_string(),
224 nodesize: None,
225 indexarr: None,
226 focused: false,
227 magnified: false,
228 ephemeral: false,
229 targetblockid: String::new(),
230 position: String::new(),
231 });
232 source_layout.pendingbackendactions = Some(actions);
233 store.update(&mut source_layout)?;
234
235 let new_ws = create_workspace(store, "")?;
237 let new_tab = create_tab(store, &new_ws.oid)?;
239
240 let mut new_tab = store.must_get::<Tab>(&new_tab.oid)?;
242 new_tab.blockids.push(block_id.to_string());
243 store.update(&mut new_tab)?;
244
245 let mut layout = store.must_get::<LayoutState>(&new_tab.layoutstate)?;
249 let node_id = Uuid::new_v4().to_string();
250 layout.rootnode = Some(LayoutNode {
251 id: node_id.clone(),
252 flex_direction: FlexDirection::Row,
253 size: 1.0,
254 children: Vec::new(),
255 data: Some(LayoutNodeData {
256 block_id: block_id.to_string(),
257 ..Default::default()
258 }),
259 ..Default::default()
260 });
261 layout.leaforder = Some(vec![LeafOrderEntry {
262 nodeid: node_id,
263 blockid: block_id.to_string(),
264 }]);
265 store.update(&mut layout)?;
266
267 block.parentoref = format!("tab:{}", new_tab.oid);
269 store.update(&mut block)?;
270
271 if auto_close_source && source_tab.blockids.is_empty() {
273 let ws = store.must_get::<Workspace>(source_ws_id)?;
274 let total_tabs = ws.tabids.len() + ws.pinnedtabids.len();
275 if total_tabs > 1 {
276 tracing::info!(source_tab = %source_tab_id, "[dnd] auto-closing empty source tab after tear-off");
277 delete_tab(store, source_ws_id, source_tab_id)?;
278 }
279 }
280
281 tracing::info!(
282 block_id = %block_id,
283 new_ws = %new_ws.oid,
284 new_tab = %new_tab.oid,
285 "[dnd] tear_off_block complete"
286 );
287 store.must_get::<Workspace>(&new_ws.oid)
289}
290
291pub fn restore_torn_off_tab(
308 store: &WaveStore,
309 tab_id: &str,
310 source_ws_id: &str,
311 dest_ws_id: &str,
312 insert_index: Option<usize>,
313 was_pinned: bool,
314) -> Result<(), StoreError> {
315 tracing::info!(
316 tab_id = %tab_id,
317 source_ws = %source_ws_id,
318 dest_ws = %dest_ws_id,
319 insert_index = ?insert_index,
320 was_pinned = %was_pinned,
321 "[dnd] restore_torn_off_tab"
322 );
323 if source_ws_id == dest_ws_id {
324 tracing::debug!("[dnd] restore_torn_off_tab: same workspace, no-op");
325 return Ok(());
326 }
327
328 store.with_tx(|tx| {
329 let _tab = tx.must_get::<Tab>(tab_id)?;
336 let mut source_ws = tx.must_get::<Workspace>(source_ws_id)?;
337 let mut dest_ws = tx.must_get::<Workspace>(dest_ws_id)?;
338
339 source_ws.tabids.retain(|id| id != tab_id);
343 source_ws.pinnedtabids.retain(|id| id != tab_id);
344 let source_now_empty =
345 source_ws.tabids.is_empty() && source_ws.pinnedtabids.is_empty();
346 if source_ws.activetabid == tab_id {
347 if let Some(new_active) = source_ws
353 .tabids
354 .first()
355 .or(source_ws.pinnedtabids.first())
356 .cloned()
357 {
358 source_ws.activetabid = new_active;
359 }
360 }
361
362 dest_ws.tabids.retain(|id| id != tab_id);
366 dest_ws.pinnedtabids.retain(|id| id != tab_id);
367
368 let insert_at = if was_pinned {
373 let idx = insert_index.unwrap_or(dest_ws.pinnedtabids.len());
374 let insert_at = idx.min(dest_ws.pinnedtabids.len());
375 dest_ws.pinnedtabids.insert(insert_at, tab_id.to_string());
376 insert_at
377 } else {
378 let idx = insert_index.unwrap_or(dest_ws.tabids.len());
379 let insert_at = idx.min(dest_ws.tabids.len());
380 dest_ws.tabids.insert(insert_at, tab_id.to_string());
381 insert_at
382 };
383 dest_ws.activetabid = tab_id.to_string();
384
385 tx.update(&mut dest_ws)?;
391 if source_now_empty {
392 tracing::info!(source_ws = %source_ws_id, "[dnd] deleting empty tear-off workspace");
393 tx.delete::<Workspace>(source_ws_id)?;
394 } else {
395 tx.update(&mut source_ws)?;
396 }
397
398 tracing::info!(
399 tab_id = %tab_id,
400 dest_ws = %dest_ws_id,
401 insert_at = %insert_at,
402 was_pinned = %was_pinned,
403 "[dnd] restore_torn_off_tab complete"
404 );
405 Ok(())
406 })
407}
408
409pub fn tear_off_tab(
414 store: &WaveStore,
415 tab_id: &str,
416 source_ws_id: &str,
417) -> Result<Workspace, StoreError> {
418 tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd] tear_off_tab");
419 let mut source_ws = store.must_get::<Workspace>(source_ws_id)?;
421 let total_tabs = source_ws.tabids.len() + source_ws.pinnedtabids.len();
422 if total_tabs <= 1 {
423 tracing::warn!(tab_id = %tab_id, total_tabs = %total_tabs, "[dnd] tear_off_tab blocked: last tab");
424 return Err(StoreError::Other(
425 "cannot tear off last tab from workspace".to_string(),
426 ));
427 }
428 source_ws.tabids.retain(|id| id != tab_id);
429 source_ws.pinnedtabids.retain(|id| id != tab_id);
430 if source_ws.activetabid == tab_id {
431 let new_active = source_ws
432 .tabids
433 .first()
434 .or(source_ws.pinnedtabids.first())
435 .cloned()
436 .unwrap_or_default();
437 tracing::info!(old_active = %tab_id, new_active = %new_active, "[dnd] switching active tab after tear-off");
438 source_ws.activetabid = new_active;
439 }
440 store.update(&mut source_ws)?;
441
442 let mut new_ws = Workspace {
444 oid: Uuid::new_v4().to_string(),
445 name: String::new(),
446 tabids: vec![tab_id.to_string()],
447 pinnedtabids: vec![],
448 activetabid: tab_id.to_string(),
449 meta: MetaMapType::new(),
450 ..Default::default()
451 };
452 store.insert(&mut new_ws)?;
453
454 tracing::info!(tab_id = %tab_id, new_ws = %new_ws.oid, "[dnd] tear_off_tab complete");
455 Ok(new_ws)
456}